Skip to main content

media_pp\elements\source\compositor\windows\d3d11_video_compositor/
mod.rs

1//! GPU (D3D11) video compositor — [`D3d11VideoCompositor`] itself plus
2//! everything specific to driving it: input registration
3//! ([`D3d11VideoCompositorHandle`]), per-input placement control
4//! ([`video_handle::D3d11VideoLayerHandle`]), and the dynamic-text
5//! sibling ([`text_handle::D3d11TextLayerHandle`]) each split into their
6//! own file since neither is small enough to justify inlining here.
7
8use std::{
9    collections::HashMap,
10    ffi::c_void,
11    sync::{
12        Arc, Mutex, Weak,
13        atomic::{AtomicU64, Ordering},
14    },
15    thread,
16    time::{Duration, Instant},
17};
18
19use crate::pp_log::{PpLog, pp_info};
20use arc_swap::ArcSwapOption;
21use ffmpeg_next as ffmpeg;
22use thiserror::Error as ThisError;
23use windows::{
24    Win32::Foundation::RECT,
25    Win32::Graphics::{
26        Direct3D::{
27            D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST, D3D_SRV_DIMENSION_TEXTURE2DARRAY, Fxc::*,
28            ID3DBlob, ID3DInclude,
29        },
30        Direct3D11::*,
31        Dxgi::Common::{
32            DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_R8_UNORM,
33            DXGI_FORMAT_R8G8_UNORM, DXGI_SAMPLE_DESC,
34        },
35    },
36    core::{Interface, s},
37};
38
39mod text_handle;
40mod video_handle;
41
42pub use text_handle::{D3d11TextLayerError, D3d11TextLayerHandle};
43pub use video_handle::D3d11VideoLayerHandle;
44
45use super::super::{
46    text_layer::TextLayer,
47    video_layer::{self, LayerGeometry, MAX_DIMENSION, VideoLayer, VideoLayerError},
48};
49use crate::{
50    buffer::MediaBuffer,
51    bus::{Bus, BusEvent},
52    control::{ControlMsg, ControlReceiver, drain_control},
53    element::{Element, ElementType, Sink, Source, SourceElement, element_pp_log},
54    elements::{
55        VideoCompositorOptions,
56        filter::decoder::d3d11va_decoder::{d3d11va_texture, wrap_d3d11_texture},
57    },
58    error::Result,
59    pad::SrcPad,
60    pool::{UnboundObjectPool, UnboundObjectPoolRef},
61    schedule::PeriodicSchedule,
62};
63
64const OUTPUT_POOL_SIZE: usize = 4;
65const CONTROL_POLL_INTERVAL: Duration = Duration::from_millis(5);
66
67const BGRA_SHADER_SOURCE: &[u8] =
68    include_bytes!("../../../../../shaders/d3d11/composite_bgra.hlsl");
69const NV12_SHADER_SOURCE: &[u8] =
70    include_bytes!("../../../../../shaders/d3d11/composite_nv12.hlsl");
71
72/// Errors specific to [`D3d11VideoCompositor`].
73#[derive(Debug, ThisError)]
74pub enum D3d11VideoCompositorError {
75    #[error("windows error: {0}")]
76    Windows(#[from] windows::core::Error),
77
78    #[error(
79        "invalid output dimensions {width}x{height}; each dimension must be 1..={MAX_DIMENSION}"
80    )]
81    InvalidOutputDimensions { width: u32, height: u32 },
82
83    #[error("invalid frame rate {0}; numerator and denominator must both be positive")]
84    InvalidFrameRate(ffmpeg::Rational),
85
86    #[error(
87        "invalid layer dimensions {width}x{height}; each dimension must be 1..={MAX_DIMENSION}"
88    )]
89    InvalidLayerDimensions { width: u32, height: u32 },
90
91    #[error("layer opacity must be finite and between 0.0 and 1.0, got {0}")]
92    InvalidOpacity(f32),
93
94    #[error("input frame has invalid dimensions {width}x{height}")]
95    InvalidInputDimensions { width: u32, height: u32 },
96
97    #[error("scaled layer would exceed {MAX_DIMENSION}px: {width}x{height}")]
98    ScaledLayerTooLarge { width: u32, height: u32 },
99
100    #[error("the compositor input has been removed")]
101    SourceRemoved,
102
103    #[error(
104        "D3d11VideoCompositorInputSink only accepts Pixel::D3D11 frames, got {0:?}; \
105         upload/decode/capture to GPU first"
106    )]
107    UnsupportedFormat(ffmpeg::format::Pixel),
108
109    #[error(
110        "frame claimed the D3D11 pixel format but carries no texture — must come from \
111         D3d11Upload/D3d11Decoder/DxgiCaptureSource's GPU mode"
112    )]
113    InvalidD3d11Frame,
114
115    #[error(
116        "D3d11VideoCompositor only draws DXGI_FORMAT_B8G8R8A8_UNORM or DXGI_FORMAT_NV12 input \
117         textures, got {0:?}"
118    )]
119    UnsupportedTextureFormat(DXGI_FORMAT),
120
121    #[error("D3D11 texture array index {index} is outside ArraySize {array_size}")]
122    InvalidArrayIndex { index: isize, array_size: u32 },
123
124    #[error(
125        "frame dimensions {frame_width}x{frame_height} exceed the backing D3D11 texture's \
126         {texture_width}x{texture_height} dimensions"
127    )]
128    FrameExceedsTexture {
129        frame_width: u32,
130        frame_height: u32,
131        texture_width: u32,
132        texture_height: u32,
133    },
134
135    #[error(
136        "a Pixel::D3D11 frame's texture lives on a different ID3D11Device than this \
137         D3d11VideoCompositor was created with — every D3D11 element in one pipeline must share \
138         exactly one device for zero-copy to be valid"
139    )]
140    DeviceMismatch,
141
142    #[error(
143        "D3d11VideoCompositorInputSink only accepts decoded Video frames, got a {0}; link it \
144         after a decoder or video source"
145    )]
146    UnsupportedBuffer(&'static str),
147
148    #[error("D3d11VideoCompositor doesn't support seeking a live composition")]
149    SeekUnsupported,
150}
151
152fn map_layer_error(error: VideoLayerError) -> D3d11VideoCompositorError {
153    match error {
154        VideoLayerError::InvalidDimensions { width, height } => {
155            D3d11VideoCompositorError::InvalidLayerDimensions { width, height }
156        }
157        VideoLayerError::InvalidOpacity(opacity) => {
158            D3d11VideoCompositorError::InvalidOpacity(opacity)
159        }
160        VideoLayerError::InvalidInputDimensions { width, height } => {
161            D3d11VideoCompositorError::InvalidInputDimensions { width, height }
162        }
163        VideoLayerError::ScaledLayerTooLarge { width, height } => {
164            D3d11VideoCompositorError::ScaledLayerTooLarge { width, height }
165        }
166    }
167}
168
169struct GpuVideoInput {
170    id: video_layer::VideoInputId,
171    /// Same shape as the CPU `VideoCompositor`'s own `VideoInput` field —
172    /// `ffmpeg::frame::Video` is the same Rust type whether its pixel data
173    /// is CPU-resident or (as with every `Pixel::D3D11` frame in this
174    /// crate) a GPU texture pointer smuggled through `data[0]`. Replacing
175    /// the pointer is a lock-free atomic swap; the compositor takes a
176    /// stable `Arc` snapshot independently.
177    latest_frame: ArcSwapOption<UnboundObjectPoolRef<ffmpeg::frame::Video>>,
178    layer: Mutex<VideoLayer>,
179}
180
181struct D3d11CompositorShared {
182    inputs: Mutex<HashMap<Arc<str>, Arc<GpuVideoInput>>>,
183    next_input_id: AtomicU64,
184    /// Lets [`D3d11VideoCompositorHandle::add_text_layer`] build GPU
185    /// resources guaranteed to match this compositor's own device, without
186    /// requiring a separately-threaded-through device parameter that could
187    /// drift from the real one.
188    device: ID3D11Device,
189}
190
191/// A cheaply cloneable handle for adding and removing
192/// [`D3d11VideoCompositor`] inputs — the GPU sibling of
193/// [`crate::elements::VideoCompositorHandle`], same shape and behavior.
194#[derive(Clone)]
195pub struct D3d11VideoCompositorHandle {
196    shared: Weak<D3d11CompositorShared>,
197}
198
199/// The two endpoints created for one compositor input registration.
200pub struct D3d11VideoCompositorInput {
201    pub sink: Box<dyn Sink>,
202    pub layer: D3d11VideoLayerHandle,
203}
204
205impl D3d11VideoCompositorHandle {
206    /// Registers an input under `name` and returns its layer handle —
207    /// shared by [`Self::add_source`] (which additionally wraps a `Sink`
208    /// around the same registration) and [`Self::add_layer`] (which
209    /// returns just this handle, for handle-driven inputs like
210    /// [`crate::elements::TextLayer`] that never receive `Pipeline`
211    /// frames and would otherwise build a `Sink` only to discard it).
212    fn register_input(
213        &self,
214        name: impl Into<String>,
215        layer: VideoLayer,
216    ) -> std::result::Result<Option<D3d11VideoLayerHandle>, D3d11VideoCompositorError> {
217        video_layer::validate_layer(layer).map_err(map_layer_error)?;
218        let Some(shared) = self.shared.upgrade() else {
219            return Ok(None);
220        };
221        let name: Arc<str> = name.into().into();
222        let id = video_layer::VideoInputId(shared.next_input_id.fetch_add(1, Ordering::Relaxed));
223        let input = Arc::new(GpuVideoInput {
224            id,
225            latest_frame: ArcSwapOption::empty(),
226            layer: Mutex::new(layer),
227        });
228        shared
229            .inputs
230            .lock()
231            .unwrap()
232            .insert(name.clone(), input.clone());
233
234        Ok(Some(D3d11VideoLayerHandle {
235            id,
236            name,
237            input: Arc::downgrade(&input),
238        }))
239    }
240
241    /// Registers an input and returns its terminal Sink plus independent
242    /// runtime layer control — see
243    /// [`crate::elements::VideoCompositorHandle::add_source`]'s own docs
244    /// (identical contract: reusing `name` replaces the old registration,
245    /// old sinks/layer handles become harmlessly stale).
246    pub fn add_source(
247        &self,
248        name: impl Into<String>,
249        layer: VideoLayer,
250    ) -> std::result::Result<Option<D3d11VideoCompositorInput>, D3d11VideoCompositorError> {
251        let Some(layer_handle) = self.register_input(name, layer)? else {
252            return Ok(None);
253        };
254        Ok(Some(D3d11VideoCompositorInput {
255            sink: Box::new(D3d11VideoCompositorInputSink {
256                name: layer_handle.name.clone(),
257                pp_log: element_pp_log(ElementType::D3d11VideoCompositor, &layer_handle.name, None),
258                shared: self.shared.clone(),
259                input: layer_handle.input.clone(),
260            }),
261            layer: layer_handle,
262        }))
263    }
264
265    /// Registers an input and returns *only* its layer handle — no `Sink`
266    /// at all — for a caller that drives this input's frames directly via
267    /// [`D3d11VideoLayerHandle::set_frame`] instead of wiring a `Pipeline`
268    /// branch into it (e.g. [`crate::elements::TextLayer`]). Same
269    /// replace-on-reuse contract as [`Self::add_source`].
270    pub fn add_layer(
271        &self,
272        name: impl Into<String>,
273        layer: VideoLayer,
274    ) -> std::result::Result<Option<D3d11VideoLayerHandle>, D3d11VideoCompositorError> {
275        self.register_input(name, layer)
276    }
277
278    pub fn remove_source(&self, name: &str) {
279        if let Some(shared) = self.shared.upgrade() {
280            shared.inputs.lock().unwrap().remove(name);
281        }
282    }
283
284    pub fn source_count(&self) -> usize {
285        self.shared
286            .upgrade()
287            .map(|shared| shared.inputs.lock().unwrap().len())
288            .unwrap_or(0)
289    }
290
291    /// Registers a new text layer and returns a [`D3d11TextLayerHandle`]
292    /// ready for [`D3d11TextLayerHandle::set_text`] — the text-specific
293    /// sibling of [`Self::add_layer`] (which stays generic, unaware of
294    /// text), taking a [`TextLayer`] the same way `add_source` takes a
295    /// [`VideoLayer`]. Always uses this compositor's own device
296    /// internally, so unlike a hand-assembled `add_layer` +
297    /// separately-supplied device there's no way to accidentally construct
298    /// a `D3d11TextLayerHandle` against the wrong device — the one class
299    /// of bug a caller-supplied device would allow. Returns `None` if the
300    /// compositor has already been dropped, matching [`Self::add_layer`]'s
301    /// own contract.
302    pub fn add_text_layer(
303        &self,
304        name: impl Into<String>,
305        text_layer: TextLayer,
306    ) -> std::result::Result<Option<D3d11TextLayerHandle>, D3d11TextLayerError> {
307        let Some(device) = self.shared.upgrade().map(|shared| shared.device.clone()) else {
308            return Ok(None);
309        };
310        // Validate everything that can fail before replacing an existing
311        // registration with the same name.
312        let font = D3d11TextLayerHandle::parse_font(text_layer.font_data, text_layer.font_size)?;
313        // Placeholder rect: no text has been rasterized yet, so its exact
314        // size is unknown. `D3d11TextLayerHandle::set_text` immediately overwrites
315        // this with the real bitmap size the first time it's called.
316        let placeholder = VideoLayer::new(video_layer::VideoRect::new(
317            text_layer.x,
318            text_layer.y,
319            1,
320            1,
321        ));
322        let Some(layer) = self.add_layer(name, placeholder)? else {
323            return Ok(None);
324        };
325        Ok(Some(D3d11TextLayerHandle::new(
326            layer,
327            &device,
328            font,
329            text_layer.font_size,
330            text_layer.color,
331        )))
332    }
333}
334
335/// One terminal video input returned by
336/// [`D3d11VideoCompositorHandle::add_source`] — the GPU sibling of
337/// [`crate::elements::VideoCompositorInputSink`], same behavior (stores
338/// only the latest frame; a fast producer can't build an unbounded queue
339/// behind a slower compositor output rate).
340pub struct D3d11VideoCompositorInputSink {
341    pp_log: PpLog,
342    name: Arc<str>,
343    shared: Weak<D3d11CompositorShared>,
344    input: Weak<GpuVideoInput>,
345}
346
347impl D3d11VideoCompositorInputSink {
348    fn detach(&self) {
349        let (Some(shared), Some(input)) = (self.shared.upgrade(), self.input.upgrade()) else {
350            return;
351        };
352        let mut inputs = shared.inputs.lock().unwrap();
353        let is_current = inputs
354            .get(&self.name)
355            .is_some_and(|current| Arc::ptr_eq(current, &input));
356        if is_current {
357            inputs.remove(&self.name);
358        }
359    }
360}
361
362impl Element for D3d11VideoCompositorInputSink {
363    fn name(&self) -> Arc<str> {
364        self.name.clone()
365    }
366
367    fn element_type(&self) -> ElementType {
368        ElementType::D3d11VideoCompositor
369    }
370
371    fn pp_log(&self) -> &PpLog {
372        &self.pp_log
373    }
374
375    fn pp_log_mut(&mut self) -> &mut PpLog {
376        &mut self.pp_log
377    }
378}
379
380impl Sink for D3d11VideoCompositorInputSink {
381    fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
382        let Some(input) = self.input.upgrade() else {
383            return Ok(());
384        };
385        match buf {
386            MediaBuffer::Video(frame) => {
387                if frame.format() != ffmpeg::format::Pixel::D3D11 {
388                    return Err(D3d11VideoCompositorError::UnsupportedFormat(frame.format()).into());
389                }
390                input.latest_frame.store(Some(frame));
391                Ok(())
392            }
393            MediaBuffer::Eos => {
394                self.detach();
395                Ok(())
396            }
397            MediaBuffer::Packet(_) => {
398                Err(D3d11VideoCompositorError::UnsupportedBuffer("Packet").into())
399            }
400            MediaBuffer::Audio(_) => {
401                Err(D3d11VideoCompositorError::UnsupportedBuffer("Audio").into())
402            }
403        }
404    }
405
406    fn control(&mut self, msg: ControlMsg) -> Result<()> {
407        match msg {
408            ControlMsg::Stop => self.detach(),
409            ControlMsg::Seek(_) => {
410                if let Some(input) = self.input.upgrade() {
411                    input.latest_frame.store(None);
412                }
413            }
414            ControlMsg::Pause | ControlMsg::Resume => {}
415        }
416        Ok(())
417    }
418}
419
420#[derive(Clone)]
421struct InputSnapshot {
422    id: video_layer::VideoInputId,
423    layer: VideoLayer,
424    frame: Option<Arc<UnboundObjectPoolRef<ffmpeg::frame::Video>>>,
425}
426
427struct OutputTarget {
428    texture: ID3D11Texture2D,
429    render_target_view: ID3D11RenderTargetView,
430}
431
432#[repr(C)]
433#[derive(Clone, Copy)]
434struct LayerConstants {
435    red: [f32; 4],
436    green: [f32; 4],
437    blue: [f32; 4],
438    opacity: f32,
439    _padding: [f32; 3],
440    uv_scale: [f32; 2],
441    _uv_padding: [f32; 2],
442}
443
444impl LayerConstants {
445    fn bgra(opacity: f32, uv_scale: [f32; 2]) -> Self {
446        Self {
447            red: [0.0; 4],
448            green: [0.0; 4],
449            blue: [0.0; 4],
450            opacity,
451            _padding: [0.0; 3],
452            uv_scale,
453            _uv_padding: [0.0; 2],
454        }
455    }
456
457    fn nv12(frame: &ffmpeg::frame::Video, opacity: f32, uv_scale: [f32; 2]) -> Self {
458        let [red, green, blue] =
459            yuv_to_rgb_rows(frame.color_space(), frame.color_range(), frame.height());
460        Self {
461            red,
462            green,
463            blue,
464            opacity,
465            _padding: [0.0; 3],
466            uv_scale,
467            _uv_padding: [0.0; 2],
468        }
469    }
470}
471
472fn visible_uv_scale(
473    frame_width: u32,
474    frame_height: u32,
475    texture_width: u32,
476    texture_height: u32,
477) -> std::result::Result<[f32; 2], D3d11VideoCompositorError> {
478    if frame_width > texture_width || frame_height > texture_height {
479        return Err(D3d11VideoCompositorError::FrameExceedsTexture {
480            frame_width,
481            frame_height,
482            texture_width,
483            texture_height,
484        });
485    }
486    Ok([
487        frame_width as f32 / texture_width as f32,
488        frame_height as f32 / texture_height as f32,
489    ])
490}
491
492/// Composites the latest frames from any number of independent GPU-backed
493/// input pipelines into one fixed-rate opaque `Pixel::D3D11` (BGRA)
494/// stream, entirely on the GPU via a D3D11 pixel shader — the D3D11
495/// sibling of [`crate::elements::VideoCompositor`] (which does the same
496/// job on the CPU via `libswscale`). Same [`VideoLayer`]/[`video_layer::VideoRect`]/
497/// [`video_layer::VideoFit`] API — a caller's layer-control code doesn't
498/// change shape when switching between the two.
499///
500/// Every input must already be a `Pixel::D3D11` frame (from
501/// [`crate::elements::D3d11Upload`], [`crate::elements::D3d11Decoder`], or
502/// [`crate::elements::DxgiCaptureSource`]'s GPU mode) on the exact same
503/// `ID3D11Device`/shared context this compositor was built with — see
504/// [`D3d11VideoCompositor::new`]'s own docs on why `context` specifically
505/// must be shared, not just the device.
506///
507/// Like [`crate::elements::VideoCompositor`], this is a [`SourceElement`],
508/// not a conventional one-input filter: upstream pipelines terminate at
509/// the sinks returned by [`D3d11VideoCompositorHandle::add_source`], while
510/// this element's own pipeline drives output on its independent clock.
511pub struct D3d11VideoCompositor {
512    pp_log: PpLog,
513    name: Arc<str>,
514    shared: Arc<D3d11CompositorShared>,
515    options: VideoCompositorOptions,
516    frame_interval: Duration,
517    frame_index: i64,
518    device: ID3D11Device,
519    context: Arc<Mutex<ID3D11DeviceContext>>,
520    vertex_shader: ID3D11VertexShader,
521    bgra_pixel_shader: ID3D11PixelShader,
522    nv12_pixel_shader: ID3D11PixelShader,
523    sampler: ID3D11SamplerState,
524    blend_state: ID3D11BlendState,
525    rasterizer_state: ID3D11RasterizerState,
526    layer_buffer: ID3D11Buffer,
527    /// Checked-out frames remain unavailable until every downstream `Arc`
528    /// is dropped. The unbounded pool grows when all prefilled frames are
529    /// still queued or being consumed instead of aliasing a live texture.
530    output_pool: UnboundObjectPool<ffmpeg::frame::Video>,
531    /// Immutable RTVs cached by the corresponding texture's COM pointer.
532    output_views: HashMap<usize, ID3D11RenderTargetView>,
533    pad: SrcPad,
534}
535
536// SAFETY: every field is either a `windows-rs` COM interface wrapper
537// (free-threaded for the calls used here, the context-touching ones behind
538// `context`'s own `Mutex`) or plain data. `&mut self` on every
539// method that touches non-`Arc`/`Mutex` state rules out concurrent access
540// to those parts from multiple threads.
541unsafe impl Send for D3d11VideoCompositor {}
542
543impl D3d11VideoCompositor {
544    /// `device` must be the same `ID3D11Device` every producer feeding this
545    /// compositor's inputs uses. `context` must be the exact same shared
546    /// `Arc<Mutex<ID3D11DeviceContext>>` every other context-touching D3D11
547    /// consumer in this pipeline uses (e.g.
548    /// `render_common::D3d11GpuContext::context()`) — reading a texture via
549    /// `CopySubresourceRegion`/`Map` ([`crate::elements::D3d11Download`]) or drawing
550    /// with it (a window renderer) are both context-level operations, and
551    /// only funneling every one of them through one shared, mutex-guarded
552    /// context is what lets this whole stack skip explicit GPU fences once
553    /// a consumer has submitted its read. Output texture reuse is governed
554    /// separately by the output frame's downstream `Arc` lifetime, because
555    /// a frame waiting inside a `Queue` has not submitted that read yet.
556    pub fn new(
557        name: impl Into<String>,
558        device: &ID3D11Device,
559        context: Arc<Mutex<ID3D11DeviceContext>>,
560        options: VideoCompositorOptions,
561    ) -> std::result::Result<(Self, D3d11VideoCompositorHandle), D3d11VideoCompositorError> {
562        validate_output_options(options)?;
563        let name: Arc<str> = name.into().into();
564        let pp_log = element_pp_log(ElementType::D3d11VideoCompositor, &name, None);
565        let shared = Arc::new(D3d11CompositorShared {
566            inputs: Mutex::new(HashMap::new()),
567            next_input_id: AtomicU64::new(1),
568            device: device.clone(),
569        });
570        let frame_interval = Duration::from_secs_f64(
571            options.frame_rate.denominator() as f64 / options.frame_rate.numerator() as f64,
572        );
573
574        let (
575            vertex_shader,
576            bgra_pixel_shader,
577            nv12_pixel_shader,
578            sampler,
579            blend_state,
580            rasterizer_state,
581            layer_buffer,
582        ) = unsafe { build_pipeline_state(device)? };
583
584        pp_info!(
585            pp_log: &pp_log,
586            "created: {}x{}, frame_rate={}, format=D3D11(BGRA)",
587            options.width,
588            options.height,
589            options.frame_rate
590        );
591        Ok((
592            Self {
593                name: name.clone(),
594                pp_log,
595                shared: shared.clone(),
596                options,
597                frame_interval,
598                frame_index: 0,
599                device: device.clone(),
600                context,
601                vertex_shader,
602                bgra_pixel_shader,
603                nv12_pixel_shader,
604                sampler,
605                blend_state,
606                rasterizer_state,
607                layer_buffer,
608                output_pool: UnboundObjectPool::new(
609                    OUTPUT_POOL_SIZE,
610                    ffmpeg::frame::Video::empty,
611                    |_| {},
612                ),
613                output_views: HashMap::new(),
614                pad: SrcPad::new(format!("{name}_src")),
615            },
616            D3d11VideoCompositorHandle {
617                shared: Arc::downgrade(&shared),
618            },
619        ))
620    }
621
622    pub fn width(&self) -> u32 {
623        self.options.width
624    }
625
626    pub fn height(&self) -> u32 {
627        self.options.height
628    }
629
630    pub fn frame_rate(&self) -> ffmpeg::Rational {
631        self.options.frame_rate
632    }
633
634    pub fn time_base(&self) -> ffmpeg::Rational {
635        ffmpeg::Rational::new(
636            self.options.frame_rate.denominator(),
637            self.options.frame_rate.numerator(),
638        )
639    }
640
641    fn snapshots(&self) -> Vec<InputSnapshot> {
642        let inputs: Vec<_> = self
643            .shared
644            .inputs
645            .lock()
646            .unwrap()
647            .values()
648            .cloned()
649            .collect();
650        inputs
651            .into_iter()
652            .map(|input| InputSnapshot {
653                id: input.id,
654                layer: *input.layer.lock().unwrap(),
655                frame: input.latest_frame.load_full(),
656            })
657            .collect()
658    }
659
660    /// Composites one output frame. A single bad input layer (mismatched
661    /// device, unsupported texture format, oversized frame, ...) is
662    /// skipped and reported on `bus` as a [`BusEvent::Error`] rather than
663    /// failing the whole call — same "elements don't die on data errors"
664    /// contract as the rest of this crate (see
665    /// [`crate::element::SourceElement::run`]'s own docs: a `Result::Err`
666    /// returned from here would propagate all the way out of `run()` and
667    /// end this compositor's output permanently, which one misbehaving
668    /// input has no business doing). Only a genuine infrastructure failure
669    /// (e.g. `create_output_target` failing) still returns `Err`.
670    fn compose_frame(
671        &mut self,
672        bus: &Bus,
673    ) -> std::result::Result<UnboundObjectPoolRef<ffmpeg::frame::Video>, D3d11VideoCompositorError>
674    {
675        let mut snapshots = self.snapshots();
676        snapshots.sort_by(|left, right| {
677            left.layer
678                .z_index
679                .cmp(&right.layer.z_index)
680                .then_with(|| left.id.cmp(&right.id))
681        });
682
683        let (canvas_width, canvas_height) = (self.options.width, self.options.height);
684        let mut output_frame = self.output_pool.get();
685        if d3d11va_texture(&output_frame).is_none() {
686            let target =
687                unsafe { create_output_target(&self.device, canvas_width, canvas_height)? };
688            let key = target.texture.as_raw() as usize;
689            self.output_views
690                .insert(key, target.render_target_view.clone());
691            *output_frame = wrap_d3d11_texture(target.texture, canvas_width, canvas_height);
692        }
693        let (output_raw, _) = d3d11va_texture(&output_frame)
694            .expect("output pool frames are initialized immediately after checkout");
695        let output_texture = unsafe {
696            ID3D11Texture2D::from_raw_borrowed(&output_raw)
697                .expect("pooled output texture pointer must not be null")
698                .clone()
699        };
700        let output_view = self
701            .output_views
702            .get(&(output_texture.as_raw() as usize))
703            .expect("every initialized output texture has a cached RTV")
704            .clone();
705
706        let context = self
707            .context
708            .lock()
709            .unwrap_or_else(|poisoned| poisoned.into_inner());
710        unsafe {
711            let background = &self.options.background;
712            context.ClearRenderTargetView(
713                &output_view,
714                &[
715                    f32::from(background.red) / 255.0,
716                    f32::from(background.green) / 255.0,
717                    f32::from(background.blue) / 255.0,
718                    1.0,
719                ],
720            );
721            context.OMSetRenderTargets(Some(&[Some(output_view)]), None);
722            context.OMSetBlendState(&self.blend_state, None, 0xffff_ffff);
723            context.RSSetState(&self.rasterizer_state);
724            context.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
725            context.VSSetShader(&self.vertex_shader, None);
726            context.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
727            context.PSSetConstantBuffers(0, Some(&[Some(self.layer_buffer.clone())]));
728
729            for snapshot in &snapshots {
730                if !snapshot.layer.visible || snapshot.layer.opacity == 0.0 {
731                    continue;
732                }
733                let Some(frame) = &snapshot.frame else {
734                    continue;
735                };
736                if let Err(error) = self.draw_layer(
737                    &context,
738                    frame,
739                    &snapshot.layer,
740                    canvas_width,
741                    canvas_height,
742                ) {
743                    // Skip just this layer — a bad frame on one input (wrong
744                    // device, unsupported format, ...) is a data error, not
745                    // grounds to fail the whole composited output.
746                    bus.post(
747                        &self.pp_log,
748                        BusEvent::Error {
749                            element_type: ElementType::D3d11VideoCompositor,
750                            name: self.name.clone(),
751                            error: error.into(),
752                        },
753                    );
754                }
755            }
756
757            // Always release resource bindings, including when a layer
758            // above was skipped after earlier layers had already drawn.
759            context.PSSetShaderResources(0, Some(&[None, None]));
760            context.OMSetRenderTargets(None, None);
761        };
762        drop(context);
763
764        output_frame.set_pts(Some(self.frame_index));
765        output_frame.set_color_space(ffmpeg::color::Space::RGB);
766        output_frame.set_color_range(ffmpeg::color::Range::JPEG);
767        self.frame_index += 1;
768        Ok(output_frame)
769    }
770
771    unsafe fn draw_layer(
772        &self,
773        context: &ID3D11DeviceContext,
774        frame: &ffmpeg::frame::Video,
775        layer: &VideoLayer,
776        canvas_width: u32,
777        canvas_height: u32,
778    ) -> std::result::Result<(), D3d11VideoCompositorError> {
779        let (texture_raw, index) =
780            d3d11va_texture(frame).ok_or(D3d11VideoCompositorError::InvalidD3d11Frame)?;
781        // Safety: `texture_raw` is a borrowed raw `ID3D11Texture2D*` — see
782        // `d3d11va_texture`'s own docs; `.clone()` (`AddRef`) gives an
783        // independently ref-counted handle valid for this draw call.
784        let texture = unsafe {
785            ID3D11Texture2D::from_raw_borrowed(&texture_raw)
786                .expect("D3d11 frame's texture pointer must not be null")
787                .clone()
788        };
789
790        let texture_device =
791            unsafe { texture.GetDevice() }.map_err(D3d11VideoCompositorError::from)?;
792        if texture_device.as_raw() != self.device.as_raw() {
793            return Err(D3d11VideoCompositorError::DeviceMismatch);
794        }
795
796        let mut desc = D3D11_TEXTURE2D_DESC::default();
797        unsafe { texture.GetDesc(&mut desc) };
798        if index < 0 || index as u64 >= u64::from(desc.ArraySize) {
799            return Err(D3d11VideoCompositorError::InvalidArrayIndex {
800                index,
801                array_size: desc.ArraySize,
802            });
803        }
804        let array_index = index as u32;
805        let uv_scale = visible_uv_scale(frame.width(), frame.height(), desc.Width, desc.Height)?;
806
807        let geometry =
808            video_layer::layer_geometry(frame.width(), frame.height(), layer.rect, layer.fit)
809                .map_err(map_layer_error)?;
810        let Some((viewport, scissor)) = clipped_viewport(&geometry, canvas_width, canvas_height)
811        else {
812            return Ok(());
813        };
814
815        let constants = match desc.Format {
816            DXGI_FORMAT_B8G8R8A8_UNORM => LayerConstants::bgra(layer.opacity, uv_scale),
817            DXGI_FORMAT_NV12 => LayerConstants::nv12(frame, layer.opacity, uv_scale),
818            other => return Err(D3d11VideoCompositorError::UnsupportedTextureFormat(other)),
819        };
820        unsafe {
821            context.UpdateSubresource(
822                &self.layer_buffer,
823                0,
824                None,
825                (&raw const constants).cast::<c_void>(),
826                0,
827                0,
828            );
829            context.RSSetViewports(Some(&[viewport]));
830            context.RSSetScissorRects(Some(&[scissor]));
831        }
832
833        match desc.Format {
834            DXGI_FORMAT_B8G8R8A8_UNORM => {
835                let srv_desc = plane_srv_desc(DXGI_FORMAT_B8G8R8A8_UNORM, array_index);
836                let mut srv = None;
837                unsafe {
838                    self.device
839                        .CreateShaderResourceView(&texture, Some(&srv_desc), Some(&mut srv))
840                        .map_err(D3d11VideoCompositorError::from)?;
841                    context.PSSetShader(&self.bgra_pixel_shader, None);
842                    context.PSSetShaderResources(0, Some(&[srv]));
843                    context.Draw(3, 0);
844                }
845            }
846            DXGI_FORMAT_NV12 => {
847                let luma_desc = plane_srv_desc(DXGI_FORMAT_R8_UNORM, array_index);
848                let chroma_desc = plane_srv_desc(DXGI_FORMAT_R8G8_UNORM, array_index);
849                let mut luma_srv = None;
850                let mut chroma_srv = None;
851                unsafe {
852                    self.device
853                        .CreateShaderResourceView(&texture, Some(&luma_desc), Some(&mut luma_srv))
854                        .map_err(D3d11VideoCompositorError::from)?;
855                    self.device
856                        .CreateShaderResourceView(
857                            &texture,
858                            Some(&chroma_desc),
859                            Some(&mut chroma_srv),
860                        )
861                        .map_err(D3d11VideoCompositorError::from)?;
862                    context.PSSetShader(&self.nv12_pixel_shader, None);
863                    context.PSSetShaderResources(0, Some(&[luma_srv, chroma_srv]));
864                    context.Draw(3, 0);
865                }
866            }
867            _ => unreachable!("texture format was validated before updating constants"),
868        }
869        Ok(())
870    }
871
872    fn push_frame(&mut self, bus: &Bus) -> std::result::Result<(), D3d11VideoCompositorError> {
873        let output = self.compose_frame(bus)?;
874        if let Err(error) = self.pad.push(MediaBuffer::Video(Arc::new(output))) {
875            bus.post(
876                &self.pp_log,
877                BusEvent::Error {
878                    element_type: ElementType::D3d11VideoCompositor,
879                    name: self.name.clone(),
880                    error,
881                },
882            );
883        }
884        Ok(())
885    }
886}
887
888impl Element for D3d11VideoCompositor {
889    fn name(&self) -> Arc<str> {
890        self.name.clone()
891    }
892
893    fn element_type(&self) -> ElementType {
894        ElementType::D3d11VideoCompositor
895    }
896
897    fn pp_log(&self) -> &PpLog {
898        &self.pp_log
899    }
900
901    fn pp_log_mut(&mut self) -> &mut PpLog {
902        &mut self.pp_log
903    }
904}
905
906impl Source for D3d11VideoCompositor {
907    fn src_pads(&mut self) -> &mut [SrcPad] {
908        std::slice::from_mut(&mut self.pad)
909    }
910}
911
912impl SourceElement for D3d11VideoCompositor {
913    fn run(&mut self, control: &ControlReceiver, bus: &Bus) -> Result<()> {
914        pp_info!(self, "started");
915        let mut schedule = PeriodicSchedule::new(self.frame_interval, Instant::now());
916        loop {
917            let outcome = drain_control(control, self, bus)?;
918            if outcome.stopped {
919                pp_info!(self, "stopped");
920                return Ok(());
921            }
922            if outcome.paused_for > Duration::ZERO {
923                schedule.resume_after_pause(outcome.paused_for, Instant::now());
924            }
925
926            let now = Instant::now();
927            if !schedule.is_due(now) {
928                thread::sleep(schedule.remaining(now).min(CONTROL_POLL_INTERVAL));
929                continue;
930            }
931
932            self.push_frame(bus)?;
933            schedule.advance_after_tick(Instant::now());
934        }
935    }
936
937    fn seek(&mut self, _target: Duration) -> Result<Duration> {
938        Err(D3d11VideoCompositorError::SeekUnsupported.into())
939    }
940}
941
942fn validate_output_options(
943    options: VideoCompositorOptions,
944) -> std::result::Result<(), D3d11VideoCompositorError> {
945    if options.width == 0
946        || options.height == 0
947        || options.width > MAX_DIMENSION
948        || options.height > MAX_DIMENSION
949    {
950        return Err(D3d11VideoCompositorError::InvalidOutputDimensions {
951            width: options.width,
952            height: options.height,
953        });
954    }
955    if options.frame_rate.numerator() <= 0 || options.frame_rate.denominator() <= 0 {
956        return Err(D3d11VideoCompositorError::InvalidFrameRate(
957            options.frame_rate,
958        ));
959    }
960    Ok(())
961}
962
963/// Builds three affine rows that turn normalized `(Y, Cb, Cr, 1)` samples
964/// into RGB. Unspecified color metadata follows the common SD/HD fallback:
965/// BT.601 through 576 lines and BT.709 above it; unspecified range is
966/// treated as MPEG/limited, matching ordinary decoded NV12 video.
967fn yuv_to_rgb_rows(
968    space: ffmpeg::color::Space,
969    range: ffmpeg::color::Range,
970    height: u32,
971) -> [[f32; 4]; 3] {
972    let (kr, kb) = match space {
973        ffmpeg::color::Space::BT709 => (0.2126f32, 0.0722f32),
974        ffmpeg::color::Space::BT2020NCL | ffmpeg::color::Space::BT2020CL => (0.2627f32, 0.0593f32),
975        ffmpeg::color::Space::FCC => (0.30f32, 0.11f32),
976        ffmpeg::color::Space::SMPTE240M => (0.212f32, 0.087f32),
977        ffmpeg::color::Space::Unspecified if height > 576 => (0.2126f32, 0.0722f32),
978        _ => (0.299f32, 0.114f32),
979    };
980    let kg = 1.0 - kr - kb;
981    let (y_offset, y_scale, chroma_scale) = match range {
982        ffmpeg::color::Range::JPEG => (0.0, 1.0, 1.0),
983        ffmpeg::color::Range::MPEG | ffmpeg::color::Range::Unspecified => {
984            (16.0 / 255.0, 255.0 / 219.0, 255.0 / 224.0)
985        }
986    };
987    let chroma_offset = 128.0 / 255.0;
988    let red_cr = 2.0 * (1.0 - kr) * chroma_scale;
989    let blue_cb = 2.0 * (1.0 - kb) * chroma_scale;
990    let green_cb = -2.0 * kb * (1.0 - kb) / kg * chroma_scale;
991    let green_cr = -2.0 * kr * (1.0 - kr) / kg * chroma_scale;
992    let offset = |cb: f32, cr: f32| -y_scale * y_offset - cb * chroma_offset - cr * chroma_offset;
993
994    [
995        [y_scale, 0.0, red_cr, offset(0.0, red_cr)],
996        [y_scale, green_cb, green_cr, offset(green_cb, green_cr)],
997        [y_scale, blue_cb, 0.0, offset(blue_cb, 0.0)],
998    ]
999}
1000
1001/// Turns pixel-space [`LayerGeometry`] into a D3D11 viewport (where the
1002/// whole scaled image is drawn) plus a scissor rect (what's actually kept,
1003/// clipped to the layer's own rect *and* the canvas) — the GPU equivalent
1004/// of the CPU `VideoCompositor`'s `blend_bgra` clipping math, computed
1005/// once instead of per output pixel. `None` if the clipped area is empty
1006/// (nothing to draw).
1007fn clipped_viewport(
1008    geometry: &LayerGeometry,
1009    canvas_width: u32,
1010    canvas_height: u32,
1011) -> Option<(D3D11_VIEWPORT, RECT)> {
1012    let output_width = i64::from(canvas_width);
1013    let output_height = i64::from(canvas_height);
1014    let clip_left = i64::from(geometry.clip.x).max(0);
1015    let clip_top = i64::from(geometry.clip.y).max(0);
1016    let clip_right =
1017        (i64::from(geometry.clip.x) + i64::from(geometry.clip.width)).min(output_width);
1018    let clip_bottom =
1019        (i64::from(geometry.clip.y) + i64::from(geometry.clip.height)).min(output_height);
1020    let left = geometry.image_x.max(clip_left);
1021    let top = geometry.image_y.max(clip_top);
1022    let right = (geometry.image_x + i64::from(geometry.image_width)).min(clip_right);
1023    let bottom = (geometry.image_y + i64::from(geometry.image_height)).min(clip_bottom);
1024    if left >= right || top >= bottom {
1025        return None;
1026    }
1027
1028    let viewport = D3D11_VIEWPORT {
1029        TopLeftX: geometry.image_x as f32,
1030        TopLeftY: geometry.image_y as f32,
1031        Width: geometry.image_width as f32,
1032        Height: geometry.image_height as f32,
1033        MinDepth: 0.0,
1034        MaxDepth: 1.0,
1035    };
1036    let scissor = RECT {
1037        left: left as i32,
1038        top: top as i32,
1039        right: right as i32,
1040        bottom: bottom as i32,
1041    };
1042    Some((viewport, scissor))
1043}
1044
1045/// Builds a single-slice `Texture2DArray` SRV description — see
1046/// `render_common::d3d11_window_renderer`'s own `plane_srv_desc` (same
1047/// shape, duplicated here since that one is private to an example crate,
1048/// not something `lib` can depend on).
1049fn plane_srv_desc(format: DXGI_FORMAT, array_index: u32) -> D3D11_SHADER_RESOURCE_VIEW_DESC {
1050    D3D11_SHADER_RESOURCE_VIEW_DESC {
1051        Format: format,
1052        ViewDimension: D3D_SRV_DIMENSION_TEXTURE2DARRAY,
1053        Anonymous: D3D11_SHADER_RESOURCE_VIEW_DESC_0 {
1054            Texture2DArray: D3D11_TEX2D_ARRAY_SRV {
1055                MostDetailedMip: 0,
1056                MipLevels: 1,
1057                FirstArraySlice: array_index,
1058                ArraySize: 1,
1059            },
1060        },
1061    }
1062}
1063
1064#[allow(clippy::type_complexity)]
1065unsafe fn build_pipeline_state(
1066    device: &ID3D11Device,
1067) -> windows::core::Result<(
1068    ID3D11VertexShader,
1069    ID3D11PixelShader,
1070    ID3D11PixelShader,
1071    ID3D11SamplerState,
1072    ID3D11BlendState,
1073    ID3D11RasterizerState,
1074    ID3D11Buffer,
1075)> {
1076    unsafe {
1077        let vertex_bytecode = compile_shader(BGRA_SHADER_SOURCE, s!("vs_main"), s!("vs_5_0"))?;
1078        let bgra_bytecode = compile_shader(BGRA_SHADER_SOURCE, s!("ps_bgra"), s!("ps_5_0"))?;
1079        let nv12_bytecode = compile_shader(NV12_SHADER_SOURCE, s!("ps_nv12"), s!("ps_5_0"))?;
1080
1081        let mut vertex_shader = None;
1082        device.CreateVertexShader(
1083            std::slice::from_raw_parts(
1084                vertex_bytecode.GetBufferPointer().cast::<u8>(),
1085                vertex_bytecode.GetBufferSize(),
1086            ),
1087            None,
1088            Some(&mut vertex_shader),
1089        )?;
1090        let vertex_shader = vertex_shader.unwrap();
1091
1092        let mut bgra_pixel_shader = None;
1093        device.CreatePixelShader(
1094            std::slice::from_raw_parts(
1095                bgra_bytecode.GetBufferPointer().cast::<u8>(),
1096                bgra_bytecode.GetBufferSize(),
1097            ),
1098            None,
1099            Some(&mut bgra_pixel_shader),
1100        )?;
1101        let bgra_pixel_shader = bgra_pixel_shader.unwrap();
1102
1103        let mut nv12_pixel_shader = None;
1104        device.CreatePixelShader(
1105            std::slice::from_raw_parts(
1106                nv12_bytecode.GetBufferPointer().cast::<u8>(),
1107                nv12_bytecode.GetBufferSize(),
1108            ),
1109            None,
1110            Some(&mut nv12_pixel_shader),
1111        )?;
1112        let nv12_pixel_shader = nv12_pixel_shader.unwrap();
1113
1114        let sampler_desc = D3D11_SAMPLER_DESC {
1115            Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
1116            AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
1117            AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
1118            AddressW: D3D11_TEXTURE_ADDRESS_CLAMP,
1119            ComparisonFunc: D3D11_COMPARISON_NEVER,
1120            MaxLOD: f32::MAX,
1121            ..Default::default()
1122        };
1123        let mut sampler = None;
1124        device.CreateSamplerState(&sampler_desc, Some(&mut sampler))?;
1125        let sampler = sampler.unwrap();
1126
1127        // Standard "over" alpha compositing for RGB. Destination alpha is
1128        // deliberately left untouched by every draw (`SrcBlendAlpha =
1129        // ZERO`, `DestBlendAlpha = ONE`) so it stays at whatever the
1130        // initial `ClearRenderTargetView` set (1.0) — same "output is
1131        // always opaque" contract the CPU `VideoCompositor`'s
1132        // `blend_bgra` enforces by always writing 255 to the destination
1133        // alpha byte.
1134        let mut blend_desc = D3D11_BLEND_DESC::default();
1135        blend_desc.RenderTarget[0] = D3D11_RENDER_TARGET_BLEND_DESC {
1136            BlendEnable: true.into(),
1137            SrcBlend: D3D11_BLEND_SRC_ALPHA,
1138            DestBlend: D3D11_BLEND_INV_SRC_ALPHA,
1139            BlendOp: D3D11_BLEND_OP_ADD,
1140            SrcBlendAlpha: D3D11_BLEND_ZERO,
1141            DestBlendAlpha: D3D11_BLEND_ONE,
1142            BlendOpAlpha: D3D11_BLEND_OP_ADD,
1143            RenderTargetWriteMask: D3D11_COLOR_WRITE_ENABLE_ALL.0 as u8,
1144        };
1145        let mut blend_state = None;
1146        device.CreateBlendState(&blend_desc, Some(&mut blend_state))?;
1147        let blend_state = blend_state.unwrap();
1148
1149        // `ScissorEnable = TRUE`: every layer draw needs its own scissor
1150        // rect to crop overflow (e.g. `VideoFit::Cover`) or an
1151        // off-canvas rect — see `clipped_viewport`'s own docs. Left bound
1152        // on the shared context afterward; every renderer sharing this
1153        // context must explicitly select its own rasterizer state before
1154        // drawing, just as this compositor does.
1155        let rasterizer_desc = D3D11_RASTERIZER_DESC {
1156            FillMode: D3D11_FILL_SOLID,
1157            CullMode: D3D11_CULL_NONE,
1158            ScissorEnable: true.into(),
1159            DepthClipEnable: true.into(),
1160            ..Default::default()
1161        };
1162        let mut rasterizer_state = None;
1163        device.CreateRasterizerState(&rasterizer_desc, Some(&mut rasterizer_state))?;
1164        let rasterizer_state = rasterizer_state.unwrap();
1165
1166        let buffer_desc = D3D11_BUFFER_DESC {
1167            ByteWidth: std::mem::size_of::<LayerConstants>() as u32,
1168            Usage: D3D11_USAGE_DEFAULT,
1169            BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
1170            CPUAccessFlags: 0,
1171            MiscFlags: 0,
1172            StructureByteStride: 0,
1173        };
1174        let mut layer_buffer = None;
1175        device.CreateBuffer(&buffer_desc, None, Some(&mut layer_buffer))?;
1176        let layer_buffer = layer_buffer.unwrap();
1177
1178        Ok((
1179            vertex_shader,
1180            bgra_pixel_shader,
1181            nv12_pixel_shader,
1182            sampler,
1183            blend_state,
1184            rasterizer_state,
1185            layer_buffer,
1186        ))
1187    }
1188}
1189
1190unsafe fn compile_shader(
1191    source: &[u8],
1192    entry: windows::core::PCSTR,
1193    target: windows::core::PCSTR,
1194) -> windows::core::Result<ID3DBlob> {
1195    let mut shader = None;
1196    let mut errors = None;
1197    let flags = if cfg!(debug_assertions) {
1198        D3DCOMPILE_DEBUG | D3DCOMPILE_SKIP_OPTIMIZATION
1199    } else {
1200        D3DCOMPILE_OPTIMIZATION_LEVEL3
1201    };
1202    let result = unsafe {
1203        D3DCompile(
1204            source.as_ptr().cast::<c_void>(),
1205            source.len(),
1206            s!("d3d11_video_compositor.hlsl"),
1207            None,
1208            None::<&ID3DInclude>,
1209            entry,
1210            target,
1211            flags,
1212            0,
1213            &mut shader,
1214            Some(&mut errors),
1215        )
1216    };
1217    if let Err(error) = result {
1218        let message = errors
1219            .map(|blob| unsafe {
1220                let bytes = std::slice::from_raw_parts(
1221                    blob.GetBufferPointer().cast::<u8>(),
1222                    blob.GetBufferSize(),
1223                );
1224                String::from_utf8_lossy(bytes).into_owned()
1225            })
1226            .unwrap_or_else(|| error.message());
1227        return Err(windows::core::Error::new(
1228            windows::Win32::Foundation::E_FAIL,
1229            message,
1230        ));
1231    }
1232    Ok(shader.unwrap())
1233}
1234
1235unsafe fn create_output_target(
1236    device: &ID3D11Device,
1237    width: u32,
1238    height: u32,
1239) -> windows::core::Result<OutputTarget> {
1240    unsafe {
1241        let desc = D3D11_TEXTURE2D_DESC {
1242            Width: width,
1243            Height: height,
1244            MipLevels: 1,
1245            ArraySize: 1,
1246            Format: DXGI_FORMAT_B8G8R8A8_UNORM,
1247            SampleDesc: DXGI_SAMPLE_DESC {
1248                Count: 1,
1249                Quality: 0,
1250            },
1251            Usage: D3D11_USAGE_DEFAULT,
1252            BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
1253            CPUAccessFlags: 0,
1254            MiscFlags: 0,
1255        };
1256        let mut texture = None;
1257        device.CreateTexture2D(&desc, None, Some(&mut texture))?;
1258        let texture = texture.unwrap();
1259
1260        let mut render_target_view = None;
1261        device.CreateRenderTargetView(&texture, None, Some(&mut render_target_view))?;
1262        let render_target_view = render_target_view.unwrap();
1263
1264        Ok(OutputTarget {
1265            texture,
1266            render_target_view,
1267        })
1268    }
1269}
1270
1271#[cfg(test)]
1272mod tests {
1273    use std::collections::HashSet;
1274
1275    use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_HARDWARE;
1276
1277    use super::*;
1278    use crate::{
1279        color::Color,
1280        elements::{D3d11Download, VideoRect},
1281    };
1282
1283    struct CapturingSink {
1284        pp_log: PpLog,
1285        received: Arc<Mutex<Vec<MediaBuffer>>>,
1286    }
1287
1288    impl Element for CapturingSink {
1289        fn name(&self) -> Arc<str> {
1290            "capture".into()
1291        }
1292
1293        fn element_type(&self) -> ElementType {
1294            ElementType::Other
1295        }
1296
1297        fn pp_log(&self) -> &PpLog {
1298            &self.pp_log
1299        }
1300
1301        fn pp_log_mut(&mut self) -> &mut PpLog {
1302            &mut self.pp_log
1303        }
1304    }
1305
1306    impl Sink for CapturingSink {
1307        fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
1308            self.received.lock().unwrap().push(buf);
1309            Ok(())
1310        }
1311
1312        fn control(&mut self, _msg: ControlMsg) -> Result<()> {
1313            Ok(())
1314        }
1315    }
1316
1317    /// `None` if this machine has no working D3D11 hardware device —
1318    /// every test here skips gracefully in that case, same convention as
1319    /// `D3d11Upload`/`D3d11Decoder`/`D3d11Download`'s own hardware tests.
1320    fn try_device() -> Option<(ID3D11Device, Arc<Mutex<ID3D11DeviceContext>>)> {
1321        let mut device = None;
1322        let mut context = None;
1323        let result = unsafe {
1324            D3D11CreateDevice(
1325                None,
1326                D3D_DRIVER_TYPE_HARDWARE,
1327                Default::default(),
1328                Default::default(),
1329                None,
1330                D3D11_SDK_VERSION,
1331                Some(&mut device),
1332                None,
1333                Some(&mut context),
1334            )
1335        };
1336        if result.is_err() {
1337            eprintln!("skipping: D3D11CreateDevice failed on this machine: {result:?}");
1338            return None;
1339        }
1340        Some((
1341            device.expect("D3D11CreateDevice succeeded without producing a device"),
1342            Arc::new(Mutex::new(context.expect(
1343                "D3D11CreateDevice succeeded without producing a context",
1344            ))),
1345        ))
1346    }
1347
1348    fn bgra_texture(
1349        device: &ID3D11Device,
1350        width: u32,
1351        height: u32,
1352        bgra: [u8; 4],
1353    ) -> ID3D11Texture2D {
1354        let pixels: Vec<u8> = (0..width * height).flat_map(|_| bgra).collect();
1355        bgra_texture_from_pixels(device, width, height, &pixels)
1356    }
1357
1358    fn bgra_texture_from_pixels(
1359        device: &ID3D11Device,
1360        width: u32,
1361        height: u32,
1362        pixels: &[u8],
1363    ) -> ID3D11Texture2D {
1364        assert_eq!(pixels.len(), (width * height * 4) as usize);
1365        unsafe {
1366            let desc = D3D11_TEXTURE2D_DESC {
1367                Width: width,
1368                Height: height,
1369                MipLevels: 1,
1370                ArraySize: 1,
1371                Format: DXGI_FORMAT_B8G8R8A8_UNORM,
1372                SampleDesc: DXGI_SAMPLE_DESC {
1373                    Count: 1,
1374                    Quality: 0,
1375                },
1376                Usage: D3D11_USAGE_DEFAULT,
1377                BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
1378                CPUAccessFlags: 0,
1379                MiscFlags: 0,
1380            };
1381            let initial = D3D11_SUBRESOURCE_DATA {
1382                pSysMem: pixels.as_ptr().cast::<c_void>(),
1383                SysMemPitch: width * 4,
1384                SysMemSlicePitch: 0,
1385            };
1386            let mut texture = None;
1387            device
1388                .CreateTexture2D(&desc, Some(&initial), Some(&mut texture))
1389                .expect("CreateTexture2D failed");
1390            texture.expect("CreateTexture2D succeeded without producing a texture")
1391        }
1392    }
1393
1394    fn nv12_texture(
1395        device: &ID3D11Device,
1396        width: u32,
1397        height: u32,
1398        y: u8,
1399        cb: u8,
1400        cr: u8,
1401    ) -> ID3D11Texture2D {
1402        let row_bytes = width as usize;
1403        let luma_size = row_bytes * height as usize;
1404        let mut pixels = vec![y; luma_size + row_bytes * height.div_ceil(2) as usize];
1405        for pair in pixels[luma_size..].chunks_exact_mut(2) {
1406            pair.copy_from_slice(&[cb, cr]);
1407        }
1408        unsafe {
1409            let desc = D3D11_TEXTURE2D_DESC {
1410                Width: width,
1411                Height: height,
1412                MipLevels: 1,
1413                ArraySize: 1,
1414                Format: DXGI_FORMAT_NV12,
1415                SampleDesc: DXGI_SAMPLE_DESC {
1416                    Count: 1,
1417                    Quality: 0,
1418                },
1419                Usage: D3D11_USAGE_DEFAULT,
1420                BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
1421                CPUAccessFlags: 0,
1422                MiscFlags: 0,
1423            };
1424            let initial = D3D11_SUBRESOURCE_DATA {
1425                pSysMem: pixels.as_ptr().cast::<c_void>(),
1426                SysMemPitch: width,
1427                SysMemSlicePitch: 0,
1428            };
1429            let mut texture = None;
1430            device
1431                .CreateTexture2D(&desc, Some(&initial), Some(&mut texture))
1432                .expect("CreateTexture2D(NV12) failed");
1433            texture.expect("CreateTexture2D succeeded without producing a texture")
1434        }
1435    }
1436
1437    fn texture_key(frame: &ffmpeg::frame::Video) -> usize {
1438        d3d11va_texture(frame).expect("expected a D3D11 frame").0 as usize
1439    }
1440
1441    fn apply_color_rows(rows: [[f32; 4]; 3], y: f32, cb: f32, cr: f32) -> [f32; 3] {
1442        rows.map(|row| row[0] * y + row[1] * cb + row[2] * cr + row[3])
1443    }
1444
1445    fn pooled_video(frame: ffmpeg::frame::Video) -> MediaBuffer {
1446        let pool = UnboundObjectPool::new(0, ffmpeg::frame::Video::empty, |_| {});
1447        let mut pooled = pool.get();
1448        *pooled = frame;
1449        MediaBuffer::Video(Arc::new(pooled))
1450    }
1451
1452    /// A `Bus` with its receiver immediately dropped, for tests that don't
1453    /// care about per-layer error reporting — `Bus::post` no-ops once the
1454    /// receiving end is gone.
1455    fn test_bus() -> Bus {
1456        Bus::new().0
1457    }
1458
1459    fn download_frame(
1460        device: &ID3D11Device,
1461        context: Arc<Mutex<ID3D11DeviceContext>>,
1462        composed: UnboundObjectPoolRef<ffmpeg::frame::Video>,
1463    ) -> Arc<UnboundObjectPoolRef<ffmpeg::frame::Video>> {
1464        let (width, height) = (composed.width(), composed.height());
1465        let mut download = D3d11Download::new("download", device, context, width, height)
1466            .expect("D3d11Download::new should succeed");
1467        let received = Arc::new(Mutex::new(Vec::new()));
1468        download.src_pads()[0].link(Box::new(CapturingSink {
1469            received: received.clone(),
1470            pp_log: element_pp_log(ElementType::Other, "capture", None),
1471        }));
1472        download
1473            .consume(MediaBuffer::Video(Arc::new(composed)))
1474            .expect("download consume should succeed");
1475        let mut received = received.lock().unwrap();
1476        let MediaBuffer::Video(frame) = received.remove(0) else {
1477            panic!("expected a Video buffer");
1478        };
1479        frame
1480    }
1481
1482    fn pixel(frame: &ffmpeg::frame::Video, x: usize, y: usize) -> [u8; 4] {
1483        let offset = y * frame.stride(0) + x * 4;
1484        frame.data(0)[offset..offset + 4].try_into().unwrap()
1485    }
1486
1487    #[test]
1488    fn invalid_text_layer_does_not_replace_an_existing_registration() {
1489        let Some((device, context)) = try_device() else {
1490            return;
1491        };
1492        let options = VideoCompositorOptions {
1493            width: 4,
1494            height: 4,
1495            frame_rate: ffmpeg::Rational::new(30, 1),
1496            background: Color::BLACK,
1497        };
1498        let (_compositor, handle) =
1499            D3d11VideoCompositor::new("compositor", &device, context, options).unwrap();
1500        let existing = handle
1501            .add_layer("overlay", VideoLayer::new(VideoRect::new(0, 0, 1, 1)))
1502            .unwrap()
1503            .unwrap();
1504
1505        let result = handle.add_text_layer("overlay", TextLayer::new(vec![0, 1, 2, 3]));
1506
1507        assert!(matches!(result, Err(D3d11TextLayerError::InvalidFont(_))));
1508        assert_eq!(handle.source_count(), 1);
1509        assert!(existing.layer().is_some());
1510    }
1511
1512    #[test]
1513    fn composes_gpu_inputs_in_z_order_and_preserves_output_contract() {
1514        let Some((device, context)) = try_device() else {
1515            return;
1516        };
1517        let options = VideoCompositorOptions {
1518            width: 4,
1519            height: 4,
1520            frame_rate: ffmpeg::Rational::new(30, 1),
1521            background: Color::BLACK,
1522        };
1523        let (mut compositor, handle) =
1524            D3d11VideoCompositor::new("compositor", &device, context.clone(), options)
1525                .expect("D3d11VideoCompositor::new should succeed");
1526
1527        let mut background_layer = VideoLayer::new(VideoRect::new(0, 0, 4, 4));
1528        background_layer.fit = video_layer::VideoFit::Stretch;
1529        let mut red_sink = handle
1530            .add_source("red", background_layer)
1531            .unwrap()
1532            .unwrap()
1533            .sink;
1534
1535        let mut overlay_layer = VideoLayer::new(VideoRect::new(1, 1, 2, 2));
1536        overlay_layer.z_index = 1;
1537        overlay_layer.fit = video_layer::VideoFit::Stretch;
1538        let mut blue_sink = handle
1539            .add_source("blue", overlay_layer)
1540            .unwrap()
1541            .unwrap()
1542            .sink;
1543
1544        // BGRA byte order: [blue, green, red, alpha].
1545        let red_texture = bgra_texture(&device, 4, 4, [0, 0, 255, 255]);
1546        let blue_texture = bgra_texture(&device, 2, 2, [255, 0, 0, 255]);
1547        red_sink
1548            .consume(pooled_video(wrap_d3d11_texture(red_texture, 4, 4)))
1549            .unwrap();
1550        blue_sink
1551            .consume(pooled_video(wrap_d3d11_texture(blue_texture, 2, 2)))
1552            .unwrap();
1553
1554        let composed = compositor
1555            .compose_frame(&test_bus())
1556            .expect("compose_frame failed");
1557        assert_eq!(composed.format(), ffmpeg::format::Pixel::D3D11);
1558        assert_eq!((composed.width(), composed.height()), (4, 4));
1559        assert_eq!(composed.pts(), Some(0));
1560
1561        let downloaded = download_frame(&device, context, composed);
1562        assert_eq!(pixel(&downloaded, 0, 0), [0, 0, 255, 255], "red background");
1563        assert_eq!(pixel(&downloaded, 1, 1), [255, 0, 0, 255], "blue overlay");
1564    }
1565
1566    #[test]
1567    fn ignores_rows_outside_the_frame_visible_dimensions() {
1568        let Some((device, context)) = try_device() else {
1569            return;
1570        };
1571        let options = VideoCompositorOptions {
1572            width: 4,
1573            height: 3,
1574            frame_rate: ffmpeg::Rational::new(30, 1),
1575            background: Color::BLACK,
1576        };
1577        let (mut compositor, handle) =
1578            D3d11VideoCompositor::new("compositor", &device, context.clone(), options)
1579                .expect("D3d11VideoCompositor::new should succeed");
1580        let mut layer = VideoLayer::new(VideoRect::new(0, 0, 4, 3));
1581        layer.fit = video_layer::VideoFit::Stretch;
1582        let mut sink = handle.add_source("input", layer).unwrap().unwrap().sink;
1583
1584        // The frame exposes only the top three red rows of a four-row
1585        // texture. Sampling the full texture would blend the blue padding
1586        // row into the last visible output row.
1587        let mut pixels = Vec::with_capacity(4 * 4 * 4);
1588        for y in 0..4 {
1589            let color = if y < 3 {
1590                [0, 0, 255, 255]
1591            } else {
1592                [255, 0, 0, 255]
1593            };
1594            pixels.extend((0..4).flat_map(|_| color));
1595        }
1596        let texture = bgra_texture_from_pixels(&device, 4, 4, &pixels);
1597        sink.consume(pooled_video(wrap_d3d11_texture(texture, 4, 3)))
1598            .unwrap();
1599
1600        let composed = compositor
1601            .compose_frame(&test_bus())
1602            .expect("compose_frame failed");
1603        let downloaded = download_frame(&device, context, composed);
1604        for y in 0..3 {
1605            for x in 0..4 {
1606                assert_eq!(pixel(&downloaded, x, y), [0, 0, 255, 255]);
1607            }
1608        }
1609    }
1610
1611    #[test]
1612    fn rejects_frame_dimensions_larger_than_the_backing_texture() {
1613        let error = visible_uv_scale(1920, 1088, 1920, 1080).unwrap_err();
1614        assert!(matches!(
1615            error,
1616            D3d11VideoCompositorError::FrameExceedsTexture {
1617                frame_width: 1920,
1618                frame_height: 1088,
1619                texture_width: 1920,
1620                texture_height: 1080,
1621            }
1622        ));
1623    }
1624
1625    #[test]
1626    fn live_output_frames_keep_distinct_textures_until_the_last_arc_drops() {
1627        let Some((device, context)) = try_device() else {
1628            return;
1629        };
1630        let options = VideoCompositorOptions {
1631            width: 1,
1632            height: 1,
1633            frame_rate: ffmpeg::Rational::new(30, 1),
1634            background: Color::BLACK,
1635        };
1636        let (mut compositor, handle) =
1637            D3d11VideoCompositor::new("compositor", &device, context.clone(), options)
1638                .expect("D3d11VideoCompositor::new should succeed");
1639        let mut layer = VideoLayer::new(VideoRect::new(0, 0, 1, 1));
1640        layer.fit = video_layer::VideoFit::Stretch;
1641        let mut sink = handle.add_source("input", layer).unwrap().unwrap().sink;
1642
1643        sink.consume(pooled_video(wrap_d3d11_texture(
1644            bgra_texture(&device, 1, 1, [0, 0, 255, 255]),
1645            1,
1646            1,
1647        )))
1648        .unwrap();
1649        let first = compositor
1650            .compose_frame(&test_bus())
1651            .expect("first compose failed");
1652
1653        sink.consume(pooled_video(wrap_d3d11_texture(
1654            bgra_texture(&device, 1, 1, [255, 0, 0, 255]),
1655            1,
1656            1,
1657        )))
1658        .unwrap();
1659        let mut later = Vec::new();
1660        for _ in 0..OUTPUT_POOL_SIZE {
1661            later.push(
1662                compositor
1663                    .compose_frame(&test_bus())
1664                    .expect("later compose failed"),
1665            );
1666        }
1667
1668        let mut keys = HashSet::new();
1669        keys.insert(texture_key(&first));
1670        keys.extend(later.iter().map(|frame| texture_key(frame)));
1671        assert_eq!(
1672            keys.len(),
1673            OUTPUT_POOL_SIZE + 1,
1674            "simultaneously-live output frames must never alias one texture"
1675        );
1676
1677        let downloaded = download_frame(&device, context, first);
1678        assert_eq!(
1679            pixel(&downloaded, 0, 0),
1680            [0, 0, 255, 255],
1681            "later compositions must not overwrite a queued first frame"
1682        );
1683    }
1684
1685    #[test]
1686    fn nv12_conversion_uses_frame_color_space_and_range() {
1687        let Some((device, context)) = try_device() else {
1688            return;
1689        };
1690        let options = VideoCompositorOptions {
1691            width: 2,
1692            height: 2,
1693            frame_rate: ffmpeg::Rational::new(30, 1),
1694            background: Color::BLACK,
1695        };
1696        let (mut compositor, handle) =
1697            D3d11VideoCompositor::new("compositor", &device, context.clone(), options)
1698                .expect("D3d11VideoCompositor::new should succeed");
1699        let mut layer = VideoLayer::new(VideoRect::new(0, 0, 2, 2));
1700        layer.fit = video_layer::VideoFit::Stretch;
1701        let mut sink = handle.add_source("input", layer).unwrap().unwrap().sink;
1702        let texture = nv12_texture(&device, 2, 2, 81, 90, 240);
1703
1704        let mut bt601 = wrap_d3d11_texture(texture.clone(), 2, 2);
1705        bt601.set_color_space(ffmpeg::color::Space::SMPTE170M);
1706        bt601.set_color_range(ffmpeg::color::Range::MPEG);
1707        sink.consume(pooled_video(bt601)).unwrap();
1708        let bt601 = compositor
1709            .compose_frame(&test_bus())
1710            .expect("BT.601 compose failed");
1711        let bt601 = download_frame(&device, context.clone(), bt601);
1712
1713        let mut bt709 = wrap_d3d11_texture(texture, 2, 2);
1714        bt709.set_color_space(ffmpeg::color::Space::BT709);
1715        bt709.set_color_range(ffmpeg::color::Range::MPEG);
1716        sink.consume(pooled_video(bt709)).unwrap();
1717        let bt709 = compositor
1718            .compose_frame(&test_bus())
1719            .expect("BT.709 compose failed");
1720        let bt709 = download_frame(&device, context, bt709);
1721
1722        let pixel_601 = pixel(&bt601, 0, 0);
1723        let pixel_709 = pixel(&bt709, 0, 0);
1724        assert!(
1725            pixel_601[1].abs_diff(pixel_709[1]) >= 20,
1726            "the same NV12 sample should use different 601/709 matrices: {pixel_601:?} vs {pixel_709:?}"
1727        );
1728        assert_eq!(pixel_601[3], 255);
1729        assert_eq!(pixel_709[3], 255);
1730    }
1731
1732    #[test]
1733    fn nv12_conversion_distinguishes_limited_and_full_range() {
1734        let limited = yuv_to_rgb_rows(
1735            ffmpeg::color::Space::BT709,
1736            ffmpeg::color::Range::MPEG,
1737            1080,
1738        );
1739        let full = yuv_to_rgb_rows(
1740            ffmpeg::color::Space::BT709,
1741            ffmpeg::color::Range::JPEG,
1742            1080,
1743        );
1744        let neutral = 128.0 / 255.0;
1745        let limited_black = apply_color_rows(limited, 16.0 / 255.0, neutral, neutral);
1746        let limited_white = apply_color_rows(limited, 235.0 / 255.0, neutral, neutral);
1747        let full_black = apply_color_rows(full, 0.0, neutral, neutral);
1748        let full_white = apply_color_rows(full, 1.0, neutral, neutral);
1749
1750        for channel in limited_black.into_iter().chain(full_black) {
1751            assert!(channel.abs() < 1e-5, "black mapped to {channel}");
1752        }
1753        for channel in limited_white.into_iter().chain(full_white) {
1754            assert!((channel - 1.0).abs() < 1e-5, "white mapped to {channel}");
1755        }
1756    }
1757
1758    #[test]
1759    fn layer_handle_moves_blends_and_hides_a_live_source() {
1760        let Some((device, context)) = try_device() else {
1761            return;
1762        };
1763        let options = VideoCompositorOptions {
1764            width: 3,
1765            height: 1,
1766            frame_rate: ffmpeg::Rational::new(30, 1),
1767            background: Color::BLACK,
1768        };
1769        let (mut compositor, handle) =
1770            D3d11VideoCompositor::new("compositor", &device, context.clone(), options)
1771                .expect("D3d11VideoCompositor::new should succeed");
1772
1773        let layer = VideoLayer::new(VideoRect::new(0, 0, 1, 1));
1774        let input = handle.add_source("white", layer).unwrap().unwrap();
1775        let mut sink = input.sink;
1776        let layer_handle = input.layer;
1777
1778        let white_texture = bgra_texture(&device, 1, 1, [255, 255, 255, 255]);
1779        sink.consume(pooled_video(wrap_d3d11_texture(white_texture, 1, 1)))
1780            .unwrap();
1781
1782        layer_handle.set_rect(VideoRect::new(1, 0, 1, 1)).unwrap();
1783        layer_handle.set_opacity(0.5).unwrap();
1784        let blended = compositor
1785            .compose_frame(&test_bus())
1786            .expect("compose_frame failed");
1787        let downloaded = download_frame(&device, context.clone(), blended);
1788        assert_eq!(pixel(&downloaded, 0, 0), [0, 0, 0, 255], "background only");
1789        // 255 * 0.5 = 127.5 — the CPU VideoCompositor's software blend
1790        // rounds this to 128 (`f32::round`), but D3D11's fixed-function
1791        // blend hardware truncates instead, landing on 127. Both are
1792        // legitimate roundings of the same exact half-way value; this
1793        // one-off discrepancy is an inherent CPU-vs-GPU-blend-unit
1794        // difference, not a bug in either path.
1795        let blended_pixel = pixel(&downloaded, 1, 0);
1796        assert_eq!(
1797            blended_pixel[3], 255,
1798            "50% white over black: {blended_pixel:?}"
1799        );
1800        for channel in &blended_pixel[..3] {
1801            assert!(
1802                (127..=128).contains(channel),
1803                "50% white over black: {blended_pixel:?}"
1804            );
1805        }
1806
1807        layer_handle.set_visible(false).unwrap();
1808        let hidden = compositor
1809            .compose_frame(&test_bus())
1810            .expect("compose_frame failed");
1811        assert_eq!(hidden.pts(), Some(1));
1812        let downloaded = download_frame(&device, context, hidden);
1813        assert_eq!(pixel(&downloaded, 1, 0), [0, 0, 0, 255], "hidden layer");
1814    }
1815
1816    #[test]
1817    fn skips_a_mismatched_device_texture_and_reports_it_on_the_bus() {
1818        let Some((device_a, context_a)) = try_device() else {
1819            return;
1820        };
1821        let Some((device_b, _context_b)) = try_device() else {
1822            return;
1823        };
1824        let options = VideoCompositorOptions {
1825            width: 1,
1826            height: 1,
1827            frame_rate: ffmpeg::Rational::new(30, 1),
1828            background: Color::BLACK,
1829        };
1830        let (mut compositor, handle) =
1831            D3d11VideoCompositor::new("compositor", &device_a, context_a.clone(), options)
1832                .expect("D3d11VideoCompositor::new should succeed");
1833        let mut sink = handle
1834            .add_source("mismatched", VideoLayer::new(VideoRect::new(0, 0, 1, 1)))
1835            .unwrap()
1836            .unwrap()
1837            .sink;
1838
1839        let foreign_texture = bgra_texture(&device_b, 1, 1, [255, 255, 255, 255]);
1840        sink.consume(pooled_video(wrap_d3d11_texture(foreign_texture, 1, 1)))
1841            .unwrap();
1842
1843        let (bus, bus_rx) = Bus::new();
1844        let composed = compositor
1845            .compose_frame(&bus)
1846            .expect("a mismatched-device layer must be skipped, not fail the whole frame");
1847
1848        let error = match bus_rx
1849            .try_recv()
1850            .expect("the skipped layer should be reported on the bus")
1851        {
1852            BusEvent::Error { error, .. } => error,
1853            other => panic!("expected a BusEvent::Error, got {other:?}"),
1854        };
1855        assert!(matches!(
1856            error,
1857            crate::error::Error::D3d11VideoCompositorError(
1858                D3d11VideoCompositorError::DeviceMismatch
1859            )
1860        ));
1861
1862        let downloaded = download_frame(&device_a, context_a, composed);
1863        assert_eq!(
1864            pixel(&downloaded, 0, 0),
1865            [0, 0, 0, 255],
1866            "mismatched-device layer must not be drawn — background only"
1867        );
1868    }
1869
1870    struct TimestampSink {
1871        pp_log: PpLog,
1872        tx: crossbeam_channel::Sender<Instant>,
1873    }
1874
1875    impl Element for TimestampSink {
1876        fn name(&self) -> Arc<str> {
1877            "timestamp-recorder".into()
1878        }
1879        fn element_type(&self) -> ElementType {
1880            ElementType::Other
1881        }
1882        fn pp_log(&self) -> &PpLog {
1883            &self.pp_log
1884        }
1885        fn pp_log_mut(&mut self) -> &mut PpLog {
1886            &mut self.pp_log
1887        }
1888    }
1889
1890    impl Sink for TimestampSink {
1891        fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
1892            if matches!(buf, MediaBuffer::Video(_)) {
1893                let _ = self.tx.send(Instant::now());
1894            }
1895            Ok(())
1896        }
1897        fn control(&mut self, _msg: ControlMsg) -> Result<()> {
1898            Ok(())
1899        }
1900    }
1901
1902    /// Same regression as `VideoCompositor`'s
1903    /// `resuming_after_a_pause_preserves_output_phase` — see that test's
1904    /// docs for the full rationale. `D3d11VideoCompositor::run` had the
1905    /// identical bug (never folding `paused_for` back into `next_due`).
1906    #[test]
1907    fn resuming_after_a_pause_preserves_output_phase() {
1908        use crate::pipeline::Pipeline;
1909
1910        let Some((device, context)) = try_device() else {
1911            return;
1912        };
1913        let (tx, rx) = crossbeam_channel::unbounded();
1914        let sink = TimestampSink {
1915            tx,
1916            pp_log: element_pp_log(ElementType::Other, "timestamp-recorder", None),
1917        };
1918        let options = VideoCompositorOptions {
1919            width: 2,
1920            height: 2,
1921            frame_rate: ffmpeg::Rational::new(10, 1),
1922            background: Color::BLACK,
1923        };
1924        let (compositor, _handle) =
1925            D3d11VideoCompositor::new("compositor", &device, context, options)
1926                .expect("D3d11VideoCompositor::new should succeed");
1927
1928        let pipeline = Pipeline::new("phase-test", compositor, |source, ctx| {
1929            let branch = ctx.branch().to(Box::new(sink))?;
1930            ctx.attach(source, 0, branch)?;
1931            Ok(())
1932        })
1933        .expect("test pipeline wiring must succeed");
1934
1935        pipeline.run();
1936        for _ in 0..2 {
1937            rx.recv_timeout(Duration::from_millis(500))
1938                .expect("expected steady frames before pausing");
1939        }
1940        pipeline.pause();
1941        thread::sleep(Duration::from_millis(500));
1942
1943        let resumed_at = Instant::now();
1944        pipeline.resume();
1945        let first_after_resume = rx
1946            .recv_timeout(Duration::from_millis(500))
1947            .expect("expected a frame after resume");
1948        pipeline.stop();
1949        pipeline.bus().log_events();
1950
1951        let gap = first_after_resume.saturating_duration_since(resumed_at);
1952        assert!(
1953            gap >= Duration::from_millis(50),
1954            "expected the post-pause frame to land close to a full 100ms \
1955             interval after resume (phase preserved from before the \
1956             pause), not almost immediately (phase reset to the resume \
1957             instant): got {gap:?}"
1958        );
1959    }
1960}